import { BotClientService, CacheGuildDto } from '@boticord/bot-client'
import { ErrorCodes, Limits, serversMeiliSettings } from '@boticord/consts'
import {
  CreateServerDto,
  makeMeiliServer,
  MeiliIndexedComment,
  MeiliIndexedServer,
  Report,
  ResourceComment,
  ResourceStatus,
  ResourceUp,
  Server,
  UpdateServerDto,
} from '@boticord/resources-client'
import { User } from '@boticord/users-client'
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  forwardRef,
  Inject,
  Injectable,
  InternalServerErrorException,
  NotFoundException,
  OnModuleInit,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectRepository } from '@nestjs/typeorm'
import MeiliSearch from 'meilisearch'
import { Repository } from 'typeorm'
import { ValidationUtil } from '../validation.util'
import { ResourcesService } from './resources.service'
import { JwtPayloadDto } from '@boticord/auth'

@Injectable()
export class ServersService implements OnModuleInit {
  private readonly meili: MeiliSearch

  constructor(
    @InjectRepository(Server) private readonly servers: Repository<Server>,
    @InjectRepository(User) private readonly users: Repository<User>,
    @Inject(forwardRef(() => ResourcesService)) private readonly resources: ResourcesService,
    @InjectRepository(ResourceComment) private readonly comments: Repository<ResourceComment>,
    @InjectRepository(ResourceUp) private readonly up: Repository<ResourceUp>,
    @InjectRepository(Report) private readonly reports: Repository<Report>,
    private readonly bot: BotClientService,
    private readonly validation: ValidationUtil,
    private readonly config: ConfigService,
  ) {
    this.meili = new MeiliSearch({
      // MEILISEARCH_* is parsed by us to not conflict with meilisearch config which starts with MEILI_
      host: this.config.getOrThrow('MEILISEARCH_HOST'),
      apiKey: this.config.getOrThrow('MEILI_MASTER_KEY'),
    })
  }

  async onModuleInit() {
    await this.ensureSettings()
  }

  async delete(id: string) {
    await this.index.deleteDocument(id)

    const commentsIndex = this.meili.index<MeiliIndexedComment>('comments')
    const comments = await this.comments.find({ where: { resource: { id } } })
    await commentsIndex.deleteDocuments(comments.map(c => c.id))

    await this.comments.delete({ resource: { id } })
    await this.up.delete({ resource: { id } })
    await this.reports.delete({ resource: { id } })

    return await this.servers.delete({ id })
  }

  private get index() {
    return this.meili.index<MeiliIndexedServer>('servers')
  }

  private async ensureSettings() {
    await this.index.updateSettings(serversMeiliSettings)
  }

  async checkServiceBotAvailability(id: string) {
    return !!(await this.bot.getGuild(id))
  }

  async get(id: string, requester?: string) {
    let server = await this.servers.findOne({ where: { id }, relations: { moderators: true } })

    if (!server) {
      server = await this.servers.findOne({ where: { shortLink: id }, relations: { moderators: true } })

      if (!server) throw new NotFoundException({ code: ErrorCodes.UnknownServer })
    }

    id = server.id

    const upCount = await this.up.count({
      where: {
        resource: { id },
      },
    })

    const ups = requester ? await this.up.find({
      where: {
        resource: { id },
        user: { id: requester },
      },
    }) : undefined

    return { ...server, ups, upCount }
  }

  async changeStatus(id: string, status: ResourceStatus) {
    const server = await this.get(id)
    if (server === null) {
      throw new NotFoundException({ code: ErrorCodes.UnknownServer })
    }

    // hide server from search
    if (server.status === ResourceStatus.Public && status !== ResourceStatus.Public) {
      await this.index.deleteDocument(id)
      // else show server in search
    } else if (server.status !== ResourceStatus.Public && status === ResourceStatus.Public) {
      await this.indexate(id)
    }

    server.status = status
    return await this.servers.save(server)
  }

  /** this will return the server from bot client cache if it exists, or from the database if it doesn't, or null */
  async getGuildCache(guildID: string): Promise<CacheGuildDto | null> {
    const guild = await this.bot.getGuild(guildID)
    if (guild !== null) {
      return guild
    }

    const server = await this.servers.findOne({
      where: { id: guildID },
    })
    if (server === null) {
      return null
    }

    return {
      avatar: server.avatar,
      id: server.id,
      members: server.memberCount,
      name: server.name,
      ownerID: server.owner,
    }
  }

  async indexate(id: string) {
    const server = await this.servers.findOne({
      where: { id },
    })

    if (server === null) {
      await this.index.deleteDocument(id)
      throw new NotFoundException({ code: ErrorCodes.UnknownServer })
    }

    if (server.status !== ResourceStatus.Public) {
      await this.index.deleteDocument(id)
      return
    }

    const ratingsCount = server.ratings.reduce((a, b) => a + b.count, 0)
    const rating =
      ((server.ratings.reduce((a, b) => a + b.count * b.rating, 0) / ratingsCount) || 0.00)
        .toFixed(1)

    const totalUps = await this.up.count({
      where: {
        resource: { id: server.id },
      },
    })

    await this.index.addDocuments(
      [ makeMeiliServer(server, totalUps, parseFloat(rating), ratingsCount) ],
      { primaryKey: 'id' },
    )
  }

  /** this method should be used to edit a server */
  async modifyServer(id: string, dto: UpdateServerDto) {
    const server = await this.servers.findOne({ where: { id } })
    if (server === null) {
      throw new NotFoundException({ code: ErrorCodes.UnknownServer })
    }

    const guildCache = await this.getGuildCache(id)

    if (guildCache === null) {
      throw new NotFoundException({ code: ErrorCodes.NoServiceBot })
    }

    // if invite that was sent is invalid (or points to another server), throw error
    if (!(await this.validation.checkInviteCode(id, dto.inviteLink)))
      throw new BadRequestException({ code: ErrorCodes.InvalidInviteCode })

    const moderators = dto.moderators?.length
      ? await this.validation.resolveUsers(dto.moderators)
      : []

    server.description = dto.description
    server.inviteLink = dto.inviteLink
    server.shortDescription = dto.shortDescription
    server.tags = [ ...dto.tags ]
    server.moderators = moderators
    server.name = guildCache.name
    server.avatar = guildCache.avatar
    // owner can only be changed by updating cache(transferring owner directly in discord admin)
    server.owner = guildCache.ownerID
    server.standardBannerID = dto.standardBannerID ?? 0

    const saved = await this.servers.save(server)

    await this.indexate(server.id)

    return saved
  }

  async createServer(dto: CreateServerDto, jwt: JwtPayloadDto, ip: string) {
    const existing = await this.servers.findOne({ where: { id: dto.id } })

    if (existing !== null) throw new ConflictException({ code: ErrorCodes.ServerAlreadyExists })

    const guildCache = await this.getGuildCache(dto.id)

    if (guildCache === null) {
      throw new NotFoundException({ code: ErrorCodes.NoServiceBot })
    }

    if (guildCache.ownerID !== jwt.id) {
      throw new ForbiddenException({ code: ErrorCodes.OnlyOwnerCanAddServer })
    }

    if (process.env.NODE_ENV !== 'development') {
      const secret = this.config.get('TURNSTILE_SECRET')
      if (!secret) {
        throw new InternalServerErrorException({ code: ErrorCodes.ServiceConfiguredIncorrectly })
      }

      const response = await this.validation.validateTurnstile(secret, dto.token, ip)
      if (!response.success) {
        throw new ForbiddenException({ code: ErrorCodes.AutomatedRequestsNotAllowed })
      }
    }

    const server = new Server()

    // if invite that was sent is invalid (or points to another server), throw error
    if (!(await this.validation.checkInviteCode(dto.id, dto.inviteLink)))
      throw new BadRequestException({ code: ErrorCodes.InvalidInviteCode })

    const moderators = dto.moderators?.length
      ? await this.validation.resolveUsers(dto.moderators)
      : []

    server.id = dto.id
    server.description = dto.description
    server.inviteLink = dto.inviteLink
    server.shortDescription = dto.shortDescription
    server.name = guildCache.name
    server.avatar = guildCache.avatar
    server.moderators = moderators
    server.tags = [ ...dto.tags ]
    server.owner = guildCache.ownerID
    server.status = ResourceStatus.Public
    server.standardBannerID = dto.standardBannerID ?? 0
    server.ratings = Array.from({ length: Limits.MaxRating }, (_, i) => ({ count: 0, rating: i + 1 }))

    await this.servers.save(server)

    // this is needed to update the meili search indexes, or else the server won't be found in the search
    await this.indexate(server.id)

    await this.resources.saveTypeCache(server.id, 'server')

    return server
  }
}
